환경 변수를 설정과 비밀값으로 나누기

환경 변수를 설정과 비밀값으로 나누기

한눈에 보기

PORT, LOG_LEVEL, DATABASE_PASSWORD를 모두 process.env로 읽는다고 해서 같은 종류의 값은 아니다. 환경 변수는 전달 채널이고, 설정과 비밀값은 데이터의 성격이다. 공개 가능한 운영 설정에는 문서화와 안전한 기본값이 필요하고, 비밀값에는 접근 제어·마스킹·회전·감사가 필요하다.

목차

환경 변수라는 이름이 경계를 흐린다

Node.js 서버를 만들면 다음과 같은 코드로 시작하기 쉽다.

const port = Number(process.env.PORT ?? 3000);
const logLevel = process.env.LOG_LEVEL ?? "info";
const databaseUrl = process.env.DATABASE_URL!;
const paymentApiKey = process.env.PAYMENT_API_KEY!;

네 값 모두 process.env에서 읽으므로 한 덩어리처럼 보인다. 하지만 운영 관점에서는 전혀 다른 성질을 가진다.

공개되어도 되는가 기본값 회전 필요 접근 감사
PORT 대체로 가능 가능 불필요 불필요
LOG_LEVEL 대체로 가능 가능 불필요 불필요
DATABASE_URL 구성에 따라 민감 보통 금지 자격 증명 포함 시 필요 권장
PAYMENT_API_KEY 불가능 금지 필요 필요

PORT가 없으면 3000을 사용해도 될 수 있다. 반대로 결제 API 키가 없다고 개발용 공용 키를 기본값으로 넣으면, 운영 요청이 잘못된 계정으로 전송될 수 있다. 로그 레벨은 진단 화면에 보여도 큰 문제가 없지만 데이터베이스 URL에 사용자 이름과 비밀번호가 포함되어 있다면 그대로 출력해서는 안 된다.

문제의 원인은 “환경 변수”를 데이터 분류처럼 사용한 데 있다.

구분해야 하는 두 질문

  1. 이 값을 어떤 채널로 프로세스에 전달할 것인가?
  2. 이 값은 어떤 보안·운영 정책을 가져야 하는가?

환경 변수는 첫 번째 질문의 답 중 하나다. 설정인지 비밀값인지는 두 번째 질문의 답이다.

이 구분이 없으면 다음과 같은 일이 반복된다.

먼저 값의 성격을 네 가지로 나눈다

설정 항목을 작성할 때 적어도 다음 네 범주로 분류하면 정책을 정하기 쉬워진다.

1. 일반 런타임 설정

서비스의 동작을 조절하지만 노출 자체가 보안 사고로 이어지지 않는 값이다.

PORT=3000
LOG_LEVEL=info
REQUEST_TIMEOUT_MS=5000
FEATURE_RECOMMENDATION=true

이 값들은 다음 특성을 갖는 편이 좋다.

2. 연결 정보

호스트와 포트만 있는 연결 정보는 일반 설정에 가깝지만, 사용자 이름·비밀번호·서명 파라미터가 붙으면 비밀값이 된다.

# 비교적 공개 가능한 위치 정보
DATABASE_HOST=db.internal
DATABASE_PORT=5432

# 자격 증명이 포함되므로 비밀값
DATABASE_URL=postgresql://app-user:password@db.internal:5432/app

URL 하나에 모든 정보를 넣으면 라이브러리 사용은 간단해진다. 대신 로그, 오류 메시지, 추적 데이터에 URL 전체가 섞일 위험이 커진다. 가능하다면 공개 가능한 위치와 비밀 자격 증명을 별도 필드로 유지하고 마지막 연결 단계에서만 합친다.

3. 비밀값

노출되면 다른 시스템에 인증하거나 데이터를 해독·서명할 수 있는 값이다.

DATABASE_PASSWORD
PAYMENT_API_KEY
JWT_SIGNING_PRIVATE_KEY
OAUTH_CLIENT_SECRET
WEBHOOK_SIGNING_SECRET

비밀값에는 기본값을 두지 않는다. 저장 위치, 읽을 수 있는 주체, 로그 마스킹, 회전 주기, 폐기 방법을 함께 설계해야 한다.

4. 클라이언트 공개 설정

브라우저나 모바일 앱에 전달해야 하는 값이다.

PUBLIC_API_BASE_URL
PUBLIC_SENTRY_DSN
PUBLIC_MAP_STYLE_ID

이 값은 “환경 변수에서 왔다”는 이유로 보호되지 않는다. 최종 번들, 네트워크 응답, 앱 바이너리에서 사용자가 확인할 수 있다. 따라서 이름부터 PUBLIC_처럼 공개 범위를 드러내면 실수를 줄일 수 있다.

flowchart TD
    A[새 설정 항목] --> B{클라이언트에 전달되는가?}
    B -->|예| C[공개 설정]
    B -->|아니오| D{노출되면 인증·서명·복호화가 가능한가?}
    D -->|예| E[비밀값]
    D -->|아니오| F{자격 증명을 포함한 연결 정보인가?}
    F -->|예| E
    F -->|아니오| G[일반 런타임 설정]

분류는 이름표 붙이기로 끝나지 않는다. 코드 리뷰에서 secret, public, runtime에 따라 다른 규칙을 적용할 수 있어야 의미가 있다.

환경 변수 자체가 비밀 저장소는 아니다

환경 변수는 배포 시스템이 값을 프로세스에 전달하기에 편리하다. 이미지와 설정을 분리할 수 있고, 언어와 프레임워크에 덜 의존한다. 그러나 환경 변수에 넣었다는 사실만으로 암호화, 접근 제어, 회전, 감사가 생기지는 않는다.

환경 변수의 값은 다음 경로로 노출될 수 있다.

다음 코드는 편리하지만 위험하다.

logger.info(
  {
    env: process.env,
  },
  "application booting",
);

안전한 방법은 출력 가능한 항목을 명시적으로 고르는 것이다.

logger.info(
  {
    nodeEnv: config.runtime.nodeEnv,
    port: config.runtime.port,
    logLevel: config.runtime.logLevel,
    paymentProvider: config.runtime.paymentProvider,
  },
  "application booting",
);
마스킹은 저장소를 대신하지 않는다

로그에서 ****로 가린다고 비밀값의 저장 방식이 안전해지는 것은 아니다. 반대로 비밀 관리 시스템에 저장했다고 애플리케이션 로그가 자동으로 안전해지는 것도 아니다. 저장, 전달, 사용, 기록, 폐기를 각각 보호해야 한다.

Kubernetes의 Secret도 이름만 믿어서는 안 된다. 공식 문서는 기본 상태에서 Secret 데이터가 API 서버의 기반 저장소인 etcd에 암호화되지 않은 채 저장될 수 있다고 경고한다. 저장 시 암호화, 최소 권한 RBAC, 컨테이너별 접근 제한 같은 별도 조치가 필요하다.

브라우저로 전달되는 값은 비밀이 될 수 없다

프론트엔드 프로젝트의 환경 변수는 특히 오해하기 쉽다.

VITE_API_BASE_URL=https://api.example.test
VITE_PAYMENT_SECRET=do-not-put-a-secret-here

Vite에서 VITE_ 접두사가 붙은 값은 빌드 과정에서 클라이언트 소스에 포함된다. 개발자 도구에서 직접 참조하지 않더라도 생성된 JavaScript 파일을 검색하면 찾을 수 있다.

const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;

이 치환은 대략 다음과 같은 결과를 만든다고 생각할 수 있다.

const apiBaseUrl = "https://api.example.test";

따라서 프론트엔드에는 공개되어도 괜찮은 식별자와 설정만 둔다.

클라이언트 포함 이유
API 기본 URL 가능 사용자가 요청 주소를 볼 수 있음
공개 분석 프로젝트 ID 조건부 가능 공급자가 공개 사용을 전제로 한 경우
OAuth Client ID 가능 비밀값이 아닌 공개 식별자
OAuth Client Secret 불가능 서버가 보관해야 하는 인증 자격 증명
결제 Secret Key 불가능 서버 권한으로 결제를 수행할 수 있음
DB 비밀번호 불가능 데이터 저장소에 직접 접근 가능

클라이언트가 비밀 권한이 필요한 작업을 해야 한다면 비밀 키를 전달하는 대신 서버가 중개한다.

sequenceDiagram
    participant Browser
    participant API as Backend API
    participant Provider as External Provider

    Browser->>API: 결제 준비 요청
    Note over API: Secret Key는 서버에만 존재
    API->>Provider: Secret Key로 서명한 요청
    Provider-->>API: 짧은 수명의 client token
    API-->>Browser: 제한된 client token
    Browser->>Provider: 허용된 범위의 후속 작업

여기서도 브라우저에 전달된 짧은 수명의 토큰은 노출 가능성을 전제로 권한과 유효 시간을 제한해야 한다.

애플리케이션에는 정제된 설정만 노출한다

애플리케이션 곳곳에서 process.env를 직접 읽으면 어떤 코드가 어떤 비밀값에 접근하는지 추적하기 어렵다.

// 주문 서비스
await request(process.env.PAYMENT_API_URL!, {
  headers: {
    authorization: `Bearer ${process.env.PAYMENT_API_KEY}`,
  },
});

// 정산 서비스
const timeout = Number(process.env.PAYMENT_TIMEOUT ?? 3000);

환경 변수 읽기는 시작 경계 한 곳으로 모으고, 검증된 설정 객체를 필요한 컴포넌트에만 전달하는 편이 낫다.

type RuntimeConfig = Readonly<{
  nodeEnv: "development" | "test" | "production";
  port: number;
  logLevel: "debug" | "info" | "warn" | "error";
  paymentApiUrl: URL;
  paymentTimeoutMs: number;
}>;

type SecretConfig = Readonly<{
  databasePassword: string;
  paymentApiKey: string;
}>;

type AppConfig = Readonly<{
  runtime: RuntimeConfig;
  secrets: SecretConfig;
}>;

값을 읽는 함수도 일반 설정과 비밀값의 의도를 구분한다.

function requiredText(
  env: NodeJS.ProcessEnv,
  name: string,
): string {
  const value = env[name]?.trim();

  if (!value) {
    throw new Error(`${name} is required`);
  }

  return value;
}

function requiredSecret(
  env: NodeJS.ProcessEnv,
  name: string,
): string {
  const value = env[name];

  if (!value) {
    // 값은 절대 오류 메시지에 포함하지 않는다.
    throw new Error(`required secret ${name} is missing`);
  }

  return value;
}

그리고 조립 단계 외에는 원본 환경 객체를 전달하지 않는다.

function loadConfig(env: NodeJS.ProcessEnv): AppConfig {
  return Object.freeze({
    runtime: Object.freeze({
      nodeEnv: parseNodeEnv(env.NODE_ENV),
      port: parsePort(env.PORT ?? "3000"),
      logLevel: parseLogLevel(env.LOG_LEVEL ?? "info"),
      paymentApiUrl: new URL(
        requiredText(env, "PAYMENT_API_URL"),
      ),
      paymentTimeoutMs: parsePositiveInteger(
        env.PAYMENT_TIMEOUT_MS ?? "3000",
        "PAYMENT_TIMEOUT_MS",
      ),
    }),
    secrets: Object.freeze({
      databasePassword: requiredSecret(
        env,
        "DATABASE_PASSWORD",
      ),
      paymentApiKey: requiredSecret(
        env,
        "PAYMENT_API_KEY",
      ),
    }),
  });
}

이 객체도 모든 계층에 통째로 넘기지 않는다. 결제 클라이언트에는 결제에 필요한 값만 전달한다.

type PaymentClientOptions = Readonly<{
  baseUrl: URL;
  apiKey: string;
  timeoutMs: number;
}>;

function createPaymentClient(
  options: PaymentClientOptions,
): PaymentClient {
  // 예시를 위한 가상의 클라이언트 구현
  return new PaymentClient(options);
}

const paymentClient = createPaymentClient({
  baseUrl: config.runtime.paymentApiUrl,
  apiKey: config.secrets.paymentApiKey,
  timeoutMs: config.runtime.paymentTimeoutMs,
});

이 구조의 장점은 단순히 타입이 생기는 데 있지 않다.

비밀값을 로그와 오류에서 지킨다

비밀값은 직접 출력하지 않아도 간접적으로 새어 나갈 수 있다.

try {
  await paymentClient.charge(command);
} catch (error) {
  logger.error(
    {
      config,
      error,
    },
    "payment failed",
  );
}

config에 비밀값이 있고, HTTP 라이브러리의 오류 객체에는 요청 헤더가 들어 있을 수 있다. 두 객체 모두 직렬화되면 API 키가 남는다.

허용 목록 기반 로깅

설정 전체를 마스킹하려고 하기보다 로그에 넣을 필드를 선택한다.

function publicConfigSnapshot(config: AppConfig) {
  return {
    nodeEnv: config.runtime.nodeEnv,
    port: config.runtime.port,
    logLevel: config.runtime.logLevel,
    paymentApiOrigin: config.runtime.paymentApiUrl.origin,
    paymentTimeoutMs: config.runtime.paymentTimeoutMs,
  };
}

logger.info(
  { config: publicConfigSnapshot(config) },
  "configuration loaded",
);

구조적 마스킹

HTTP 클라이언트 오류처럼 외부 객체를 기록해야 한다면 로거에서 민감 경로를 마스킹한다.

const logger = createLogger({
  redact: [
    "req.headers.authorization",
    "req.headers.cookie",
    "error.request.headers.authorization",
    "config.secrets",
  ],
  censor: "[REDACTED]",
});

키 이름만으로 모든 비밀을 찾는 방식에는 한계가 있다. token, password라는 이름이 아닌 값도 있고, URL의 사용자 정보나 쿼리 문자열에 포함될 수도 있다. 그래서 허용 목록과 마스킹을 함께 사용한다.

비밀 문자열을 타입으로 감싸는 방법

실수로 문자열 보간하는 것을 줄이고 싶다면 비밀값 래퍼를 둘 수도 있다.

class SecretString {
  readonly #value: string;

  private constructor(value: string) {
    this.#value = value;
  }

  static from(value: string): SecretString {
    return new SecretString(value);
  }

  reveal(): string {
    return this.#value;
  }

  toString(): string {
    return "[REDACTED]";
  }

  toJSON(): string {
    return "[REDACTED]";
  }
}
const secret = SecretString.from("example-secret");

console.log(String(secret));        // [REDACTED]
console.log(JSON.stringify(secret)); // "[REDACTED]"

paymentClient.authenticate(secret.reveal());

이 래퍼는 완전한 보안 장벽이 아니다. reveal()을 호출할 수 있고 메모리에는 원문이 존재한다. 다만 평범한 로깅과 직렬화에서 발생하는 실수를 줄이는 안전장치다.

오류 메시지에 원문을 넣지 않는다

PAYMENT_API_KEY has invalid value: sk_live_...처럼 친절한 메시지는 로그 유출 경로가 된다. 변수 이름과 실패 이유만 남기고 실제 값은 출력하지 않는다.

저장과 주입 방식을 분리해서 선택한다

비밀값을 어디에 저장할지와 프로세스에 어떻게 전달할지는 별개의 결정이다.

저장 위치 프로세스 전달 장점 주의점
전용 Secret Manager 시작 시 API 조회 중앙 감사·버전·회전 부트스트랩 인증과 장애 처리 필요
전용 Secret Manager 배포 시스템이 환경 변수 주입 앱 구현 단순 실행 중 변경 반영 어려움
Kubernetes Secret 환경 변수 사용 간단 Pod 재시작 전 갱신되지 않음
Kubernetes Secret 읽기 전용 파일 마운트 파일 갱신 감지 가능 앱이 재로딩을 구현해야 함
로컬 .env 환경 변수 로드 개발 편의성 저장소 커밋과 공유 금지

환경 변수 주입은 모든 서비스에 충분히 좋은 기본 선택일 수 있다. 다만 환경 변수를 곧 비밀 관리 시스템이라고 부르지 않는 것이 중요하다. 실제 통제는 값을 보관하고 주입하는 상위 시스템에서 온다.

파일 마운트 방식은 인증서나 여러 줄의 개인 키처럼 환경 변수로 표현하기 번거로운 값에도 유용하다.

import { readFile } from "node:fs/promises";

async function loadSigningKey(
  path: string,
): Promise<SecretString> {
  const pem = await readFile(path, "utf8");

  if (!pem.includes("BEGIN PRIVATE KEY")) {
    throw new Error("signing key file has an invalid format");
  }

  return SecretString.from(pem);
}

그러나 파일을 사용한다고 자동 회전되는 것은 아니다. 파일 내용이 바뀌었을 때 애플리케이션이 다시 읽을지, 새 연결에만 적용할지, 기존 연결을 끊을지를 정해야 한다.

비밀값 회전은 저장보다 어려운 문제다

비밀 관리 시스템을 도입해도 애플리케이션이 재시작할 때만 값을 읽는다면 회전에는 배포가 필요하다. 무중단으로 회전하려면 공급자와 소비자가 구 키와 신 키가 공존하는 시간을 가져야 한다.

sequenceDiagram
    participant Operator
    participant Provider as Credential Provider
    participant App as Application

    Operator->>Provider: 새 자격 증명 V2 생성
    Operator->>App: V2 주입 또는 재로딩
    App->>Provider: V2로 인증 확인
    Operator->>Provider: V1 비활성화
    App->>App: V1 메모리·캐시 폐기

단일 비밀값을 즉시 덮어쓰는 방식은 배포 순서에 따라 장애를 만든다.

1. 공급자에서 기존 키 V1을 폐기한다.
2. 일부 애플리케이션 인스턴스는 아직 V1을 사용한다.
3. 해당 인스턴스의 요청만 인증 실패한다.
4. 롤링 재시작이 끝날 때까지 부분 장애가 지속된다.

안전한 회전 절차는 대체로 다음과 같다.

  1. 새 자격 증명 V2를 만든다.
  2. V1과 V2가 모두 유효한 중첩 구간을 만든다.
  3. 모든 인스턴스가 V2를 사용하도록 배포하거나 재로딩한다.
  4. V2 인증 성공률과 V1 사용량을 관찰한다.
  5. V1 사용이 사라진 뒤 폐기한다.
  6. 노출 사고였다면 관련 세션·토큰·파생 자격 증명도 함께 무효화한다.

JWT 서명 키처럼 검증자가 과거 토큰을 확인해야 하는 경우에는 kid로 키 버전을 구분하고, 이전 공개 키를 토큰 최대 수명 동안 유지하는 식의 별도 전략이 필요하다.

회전 가능성을 인터페이스에 반영한다

시작할 때 문자열 하나를 영구 보관하는 인터페이스보다 CredentialProvider.getCurrent()처럼 현재 자격 증명을 제공하는 추상화가 회전에 유리하다. 다만 매 요청마다 원격 Secret Manager를 호출하지 말고 짧은 캐시와 실패 시 마지막 정상 버전 사용 정책을 함께 설계한다.

로컬 개발과 배포 환경의 규칙을 나눈다

로컬에서는 .env가 편리하다. Node.js도 .env 형식과 --env-file 옵션을 공식 지원한다. 다만 .env 파일의 모든 값은 파싱된 뒤 문자열이 된다.

PORT=3000
FEATURE_RECOMMENDATION=false
REQUEST_TIMEOUT_MS=5000
console.log(typeof process.env.PORT); // "string"
console.log(process.env.FEATURE_RECOMMENDATION); // "false"

따라서 다음 코드는 잘못된다.

const recommendationEnabled =
  Boolean(process.env.FEATURE_RECOMMENDATION);

// Boolean("false")는 true다.

타입 변환과 검증은 반드시 별도로 수행해야 한다. 자세한 내용은 설정 검증을 애플리케이션 시작 시점에 해야 하는 이유에서 이어서 다룬다.

저장소에는 값이 없는 계약 파일만 둔다.

# .env.example
NODE_ENV=development
PORT=3000
LOG_LEVEL=debug
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USER=example-user
DATABASE_PASSWORD=replace-me
PAYMENT_API_URL=https://sandbox.example.test
PAYMENT_API_KEY=replace-me

replace-me는 실행 가능한 공용 자격 증명이 아니라 자리표시자여야 한다. 애플리케이션이 이 값을 실제 비밀로 받아들이지 않게 막을 수도 있다.

const forbiddenPlaceholders = new Set([
  "replace-me",
  "changeme",
  "example-secret",
]);

function requiredSecret(
  env: NodeJS.ProcessEnv,
  name: string,
): string {
  const value = env[name];

  if (!value || forbiddenPlaceholders.has(value)) {
    throw new Error(`required secret ${name} is missing`);
  }

  return value;
}

.gitignore만 믿는 것도 부족하다. 이미 추적된 파일에는 규칙이 적용되지 않고, git add -f나 잘못된 파일명으로 우회될 수 있다.

.env
.env.*
!.env.example

추가로 커밋 전 secret scanner와 CI 검사를 사용하고, 실제 값이 한 번이라도 커밋되었다면 파일을 지우는 데서 끝내지 않고 즉시 자격 증명을 회전해야 한다. Git 기록에서 문자열을 제거해도 이미 복제된 저장소와 로그까지 회수할 수는 없다.

테스트와 운영에서 확인할 항목

설정과 비밀값의 분리가 코드 구조와 운영 절차에 실제 반영되었는지 확인한다.

코드 리뷰

배포

회전 훈련

실제 노출 사고가 난 뒤 처음 회전 절차를 실행하면 늦다. 비운영 환경에서 주기적으로 연습한다.

Given: V1 자격 증명으로 여러 인스턴스가 실행 중이다.
When: V2를 생성하고 롤링 배포한다.
Then:
  - 요청 실패율이 증가하지 않는다.
  - 모든 인스턴스가 V2를 사용함을 확인할 수 있다.
  - V1을 폐기한 뒤에도 정상 동작한다.
  - 로그와 배포 기록에 V1, V2 원문이 남지 않는다.

비밀값 원문을 관측할 수 없으므로 버전 식별자를 별도로 기록하는 방식이 유용하다.

logger.info(
  {
    paymentCredentialVersion:
      process.env.PAYMENT_CREDENTIAL_VERSION,
  },
  "payment credential initialized",
);

버전 문자열 자체도 조직 정책에 따라 민감할 수 있으므로 공개 범위를 검토한다. 핵심은 원문 대신 “어떤 버전이 적용되었는가”를 관측하는 것이다.

마무리

환경 변수는 설정을 외부에서 주입하는 좋은 수단이지만 비밀 저장소와 동의어는 아니다. 같은 process.env에서 읽더라도 값의 성격에 따라 규칙이 달라야 한다.

일반 설정은 문서화·기본값·타입 검증의 대상이고, 비밀값은 최소 권한·마스킹·회전·감사의 대상이다. 클라이언트에 들어가는 값은 처음부터 공개 정보로 취급해야 한다.

가장 실용적인 출발점은 설정을 runtime, public, secrets로 나누고 원본 환경 변수를 한 곳에서만 읽는 것이다. 그 위에 안전한 로그 스냅샷, 전용 비밀 관리 시스템, 회전 절차를 차례로 얹을 수 있다. 다음 단계는 이 값들이 존재하는지만 볼 것이 아니라 타입과 범위, 값 사이의 관계까지 애플리케이션 시작 시점에 검증하는 것이다.

참고 자료

관련 노트